在前一篇文章中,我們建立了一個 JWT JSON server,用來練習如何在 Next.js 中串接 JWT 驗證,這個 JSON server 提供 POST /auth/login
的登入 API,以及 GET /products
與 GET /products/:id
兩個能夠取得產品資料的 API,而兩個產品資料 API 都必須在 header 中帶上 accessToken
,否則 JSON server 會回傳 HTTP 401,阻擋使用者獲得產品資訊。
JWT JSON server 的 repo: https://github.com/leochiu-a/fake-api-jwt-json-server
在 Next.js 中設定驗證邏輯主要是放在 API routes 中,使用 NextAuth 建立客製化的驗證流程,當使用者呼叫 NextAuth 提供的 signIn
時便會出發驗證流程,驗證成功後會得到一個 accessToken
,這個 accessToken
必須在打產品資料 API 時帶上。
現在我們已經實作完了 API routes 的部分,接下來要繼續實作在頁面中的邏輯。我們接著要實作的邏輯很單純,目標是使用者登入後可以成功瀏覽「產品列表頁面」與「產品詳細頁面」,這兩個頁面在前面的章節中已經用了很多次,這次同樣也是拿這兩個頁面來練習。
LoginForm 的樣式 https://gist.github.com/leochiu-a/62d2e9dce4d1a8b09905f35ca8bf4a8a
首先,我們要來撰寫一個登入的頁面 pages/login/index.tsx
,在這個頁面中會有一個 form 表單,表單中包含了使用者的 email
與 password
兩個欄位,以及一個登入按鈕,點擊之後會觸發 NextAuth 的驗證流程,驗證成功後轉址到產品列表頁面 /products
。
import { useState, SyntheticEvent } from "react";
import { useRouter } from "next/router";
import { signIn } from "next-auth/client";
import {
AuthSection,
Login,
ControlItem,
ControlLable,
ControlInput,
SubmitButtonWrapper,
SubmitButton,
} from "./index.style";
const LoginForm = () => {
// 使用者的狀態與登入邏輯...
return (
<AuthSection>
<Login>Login</Login>
<form onSubmit={handleSubmit}>
<ControlItem>
<ControlLable htmlFor="email">Your Email</ControlLable>
<ControlInput
type="email"
id="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</ControlItem>
<ControlItem>
<ControlLable htmlFor="password">Your Password</ControlLable>
<ControlInput
type="password"
id="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</ControlItem>
<SubmitButtonWrapper>
<SubmitButton type="submit">Login</SubmitButton>
</SubmitButtonWrapper>
</form>
</AuthSection>
);
};
export default LoginForm;
在 <LoginForm />
中管理使用者的狀態與登入的邏輯也很單純,使用兩個 useState
分別儲存使用者的 email
與 password
,建立一個 onSubmit
的 callback function,在這個 function 中會呼叫 NextAuth
提供的 signIn()
,並指定是客製化的驗證流程 credentials
,匹配的是 NextAuth 在 API routes 設定的 Providers.Credentials
。
在驗證成功後,使用 useRouter()
轉址到產品列表頁面 /products
。
import { useState, SyntheticEvent } from "react";
import { useRouter } from "next/router";
import { signIn } from "next-auth/client";
import {
AuthSection,
Login,
ControlItem,
ControlLable,
ControlInput,
SubmitButtonWrapper,
SubmitButton,
} from "./index.style";
const LoginForm = () => {
const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>("");
const router = useRouter();
const handleSubmit = async (event: SyntheticEvent) => {
event.preventDefault();
const result = await signIn("credentials", {
redirect: false,
email,
password,
});
if (result?.ok) {
router.push("/products");
}
};
// return component
};
export default LoginForm;
在這個頁面中的邏輯與在前面章節中看到的大同小異,比較不一樣的是在 getServerSideProps
中的邏輯。在使用者登入後,如果想要取得 accessToken
則可以使用 NextAuth 提供的 getSession()
,這個 function 必須帶入由 getServerSideProps
的參數 ctx
,這樣才能取得使用者的驗證訊息。
然後在取得 accessToken
後,要在 fetch
的 headers
中設定 JWT 的驗證訊息,否則打產品列表 API 伺服器會回傳 HTTP 401,禁止我們取得資料。最後,成功取得資料後,把 products
當作 props 傳入到 component 中,現在應該可以順利看到產品列表頁面。
import { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/client";
import ProductCard from "../../components/ProductCard";
import { Product } from "../../fake-data";
import { PageTitle, ProductGallery } from "./index.style";
interface HomeProps {
products: Product[];
}
const Home = ({ products }: HomeProps) => {
return (
<>
<PageTitle>商品列表</PageTitle>
<ProductGallery>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</ProductGallery>
</>
);
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
const res = await fetch(`http://localhost:8000/products`, {
headers: {
Authorization: `Bearer ${session?.accessToken}`,
},
});
const products = await res.json();
return {
props: {
products,
session,
},
};
}
export default Home;
根據 NextAuth 的說明,我們可以在 /pages/__app.ts
中新增 context provider,這樣做可以提升取得 session 的效率,例如從 useSession
的原始碼中就可以看到,它會先嘗試從 context 中取得 session,如果沒有 context 再走類似 getSession
的流程,會自動打 /session
API 從伺服器中取的驗證資訊。
由此可知,沒有 Provider
的設定是會在切換頁面時,如果頁面中剛好有 useSession
,會讓頁面多打很多次 /session
API,造成每次使用者都必須等待一段時間後才看得到內容。
import { AppProps } from "next/app";
import { Provider } from "next-auth/client";
function MyApp({ Component, pageProps }: AppProps) {
return (
<Provider session={pageProps.session}>
<Component {...pageProps} />
</Provider>
);
}
export default MyApp;
但是,實際上 Provider
有作用的前提是一個頁面是必須是 SSR,也就是使用 Next.js 的 getServerSideProps
或 getInitialProps
,讀者也可以從上述中看到 session 是透過 pageProps.session
取得,所以如果一個頁面不是 SSR,就不能得到 context API 的幫助, useSession
還是會照常打 API。
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
return { session: await getSession(ctx) };
}
// 或者
Page.getInitialProps = async (ctx) => {
return { session: await getSession(ctx) };
};
最後,我們要來實作「產品詳細頁面」中的邏輯,在這個頁面中我們使用的是 client-side rendering,並且使用到 useSWR
這個 API。
基本上邏輯與前面提到 CSR 的實作差不多,只差在我們會用到 useSession
取得使用者驗證資訊,將 accessToken
帶入到 fetch
的 header 中。
這邊要特別注意的是,我們傳入到 fetcher
中的數值變成是一個物件,這個物件包含 id
與 accessToken
,而 useSWR
觸發 fetcher
的時機是看傳入的第一個參數 key
是否改變,而如果每次渲染時 params
的記憶體位置都不同,將會導致 key
改變,最終導致 API 被無限次呼叫。
所以,為了解決這個問題要使用 useMemo
將 params
記憶起來,再重新渲染時不會造成 useSWR
傳入的第一次參數記憶體位置改變。
import { useMemo } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
import { useSession } from "next-auth/client";
import useSWR from "swr";
import { Product as ProductType } from "../../fake-data";
import ProductCard from "../../components/ProductCard";
import { PageTitle, ProductContainer, BackLink } from "./[id].style";
type Params = {
id: string;
accessToken: string;
};
const fetcher = (url: string, { id, accessToken }: Params) => {
return fetch(`http://localhost:8000${url}/${id}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
}).then((res) => res.json());
};
const Product = () => {
const router = useRouter();
const { id } = router.query;
const [session, loading] = useSession();
const params = useMemo(
() => ({ id, accessToken: session?.accessToken }),
[id, session]
);
const { data: product, error } = useSWR<ProductType>(
id && !loading ? ["/products", params] : null,
fetcher
);
if (!product || error) return <div>loading</div>;
return (
<>
<PageTitle>商品詳細頁面</PageTitle>
<BackLink>
<Link href="/products">回產品列表</Link>
</BackLink>
<ProductContainer>
<ProductCard product={product} all />
</ProductContainer>
</>
);
};
export default Product;